Win32API开发:深入浅出 Shell_TrayWnd 子类化与 Z-Order 维护

2026-03-12  13:09:50

当 WS_POPUP 遇上 Win+D:为什么置顶窗口会在 “显示桌面” 时离奇失踪?

近日我在开发一款美化软件,以实现覆盖 StartIsBack / StartAllBack 的功能。目前已完成低级钩子的注入与任务栏的子类化。仓库如下:

https://github.com/Bruce225/anabiosis

开发途中遇到一个我自昨日下午起开始着手解决的 Bug; 在历经数小时令人烦躁的修改与调试后,终得以于深更半夜修复。下简要分享思路与调试过程。

前情提要

想要在 Windows 11 上实现类似 StartAllBack 的开始按钮与开始菜单修改,大多数人的第一想法应该是在获取开始按钮的句柄 hStartBtn 后,使用 ShowWindow(hStartBtn, SW_HIDE) 语句隐藏它,并在任务栏中 “嵌入” 自己的开始按钮。

然而 Windows 11 将许多的任务栏窗口组件使用了 XAML 重绘。即使在 Spy++ 等软件上能够看到 Shell_TrayWnd 下存在 "Start" 类,但他已实质上变成不受属性控制的 “代理句柄” ,导致操作 hStartBtn 对开始按钮不构成任何影响。故开发时,选择「覆盖」而不是「隐藏并添加」是更好的思路。关于「覆盖」部分,我的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
HWND CreateOrbWindow(HINSTANCE hInstance)
{
/* 此处略去一些类定义 */

HWND hTaskbar = FindWindow(L"Shell_TrayWnd", NULL);

// 获取原 Start 按钮坐标以覆盖
RECT r = {};
if (g_hStartBtn) GetWindowRect(g_hStartBtn, &r);

int w = r.right - r.left;
int h = r.bottom - r.top;

HWND hWnd = CreateWindowExW(
WS_EX_TOPMOST | WS_EX_TOOLWINDOW, // 置顶 & 不进任务栏
className, L"Orb",
WS_POPUP,
r.left, r.top, w, h,
hTaskbar,
NULL, hInstance, NULL
);
if (hWnd) ShowWindow(hWnd, SW_SHOW);
return hWnd;
}

这相当于在原开始按钮的位置上覆盖了一个自定义 Orb 按钮。其中,指定 hTaskbar 为其父窗口是为了防止点按任务栏时自定义按钮消失。

Bug 的发现

如上的逻辑在 90% 的测试情况下都没有任何问题。快速点按开始按钮、不间断敲打 Win 键或在各种情形下点按任务栏。然而当点击任务栏最右侧 “显示桌面” 按钮,或按下 Win+D 组合键时,该 Orb 按钮会突然消失;再次单击 “显示桌面” ,当左键按下时 (WM_LBUTTONDOWN) 该 Orb 按钮重新显现,松开时 (WM_LBUTTONUP) Orb 按钮仍然消失。若继续点击 “显示桌面” ,上述过程循环往复。

我起初的解决方案是在该 Orb 按钮窗口的过程中,判定 uMsg 时加入如下定时刷新器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
LRESULT CALLBACK OrbWndProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_CREATE:
SetTimer(hwnd, 1, 50, NULL); // 50ms 响应
return 0;

case WM_TIMER:
SetWindowPos(hwnd, HWND_TOPMOST, 0, 0, 0, 0,
SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE | SWP_SHOWWINDOW);
break;

/* 其他 case 略去 */
}
return DefWindowProcW(hwnd, uMsg, wParam, lParam);
}

当快速点按 “显示桌面” 时,虽然 Orb 按钮不断重绘并显现,但仍然会在极短的间隙内闪过原版开始按钮,即使将设定的 50ms 减小也无济于事。况且,该种 Timer 轮询的方法极为消耗 CPU 性能。

调试过程

使用定时器显然不是一个好方法,于是我将他移除了。我试图通过将 CreateWindowExW 中的 WS_POPUP 改为 WS_CHILD 来防止 Orb 按钮随 hTaskbar 一起隐藏,又试图通过拦截 Win+D 可能在执行 “显示桌面” 逻辑时向我的 Orb 按钮窗口发送的隐藏信号。

这两个方法都没有解决 Bug.

偶然间,我关注到了 CreateWindowExW 中的 r.left, r.top, w, h 这些坐标与大小参数,于是将 r.left 与 r.top 修改为了 (0, 0) 以及 (0, 1000) 分别进行调试 (我使用的是 1920 x 1080 的显示器)。最终发现,当按下 “显示桌面” 时,我的 Orb 按钮完全不会被它隐藏!

尤其是 (0, 1000), 我注意到按下 “显示桌面” 时它位于任务栏上方的部分未被隐藏,本身覆盖在开始按钮的那部分消失了。也就是说,这个 Bug 本质上是一个 Z-Order 排列竞争的问题。

我猜测是如下的逻辑:在 Windows 11 中,所有置顶窗口 (WM_EX_TOPMOST 属性) 都在同一个平面的 Z 轴序列里。由于任务栏是系统的核心组件,点击 “显示桌面” 时,Explorer.exe 会为了确保任务栏不被其他窗口 (比如可能存在的全屏置顶广告或工具栏) 遮挡,强制刷新一次自己的 Z 轴排序。于是,刚好把我的 Orb 按钮压在了下面。

彻底修复

我的思路是检测按下 “显示桌面” 后窗口的位置变化 (WM_WINDOWPOSCHANGED). 原本的想法是监控开始按钮 (因为我本身代码中对任务栏进行过子类化并寻过开始菜单的句柄 hStartBtn)。 事实上,通过向 DbgView 输出调试字符串的验证方式后,我发现此时开始按钮并没有发生 WM_WINDOWPOSCHANGED. 因此需要转向检测任务栏的位置变化。

首先是在 dll 劫持函数中将劫持目标选为任务栏类 Shell_TrayWnd, 对其进行子类化,并让其处理函数变为我们编写的 NewTaskbarProc. 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void StartHijack(HMODULE hModule)
{
HWND hTaskbar = FindWindow(L"Shell_TrayWnd", NULL);

/* 调试信息略去 */

LONG_PTR oldProc = SetWindowLongPtr(hTaskbar, GWLP_WNDPROC, (LONG_PTR)NewTaskbarProc);

// 子类化
if (oldProc)
{
OldTaskbarProc = (WNDPROC)oldProc;
g_hookedWnd = hTaskbar;
}

/* 启动线程略去 */
}

其中 hModule 与专用线程相关,与本文内容无关。接着在新逻辑 NewTaskbarProc 函数中,拦截任务栏的位置变化消息 WM_WINDOWPOSCHANGED 并通过 SetWindowPos 函数将自定义 Orb 提前。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
LRESULT CALLBACK NewTaskbarProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
// 拦截窗口位置变化消息
if (uMsg == WM_WINDOWPOSCHANGED)
{
// 置顶任务栏
LRESULT ret = CallWindowProc(OldTaskbarProc, hwnd, uMsg, wParam, lParam);

// 将 Orb 提到最顶层
if (g_hOrbWnd)
{
RECT r = { 0 };
if (g_hStartBtn) GetWindowRect(g_hStartBtn, &r);
SetWindowPos(g_hOrbWnd, HWND_TOPMOST, r.left, r.top, 0, 0,
SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE);
}
return ret;
}

return CallWindowProc(OldTaskbarProc, hwnd, uMsg, wParam, lParam);
}

这样,每次点按 “显示桌面” 或 Win+D 时,我们的任务栏行为都能使得自定义 Orb 窗口被提到顶层,中间没有任何间隙 (或极小,肉眼无法察觉), 达到目的。由此 Bug 修复。


留言: